Skip to content

Break api into 2 parts#36

Open
edeandrea wants to merge 6 commits into
langfuse:mainfrom
edeandrea:separate-api
Open

Break api into 2 parts#36
edeandrea wants to merge 6 commits into
langfuse:mainfrom
edeandrea:separate-api

Conversation

@edeandrea
Copy link
Copy Markdown

@edeandrea edeandrea commented May 28, 2026

Summary

Separates the Langfuse Java SDK into a multi-module Maven project with generated API interfaces, a reference HTTP client, and Testcontainers support for integration testing.

It is mostly dependency-free. It does not use/require OkHttp, instead using Java's built-in HttpClient. It requires either Jackson 2 or 3, but does not force a user into one over the other, nor does it force which version to use.

The API has been completely decoupled to the underlying http transport, meaning the api can be re-used with other http transports.

New modules

  • langfuse-java-api -- API interfaces, model types, and SPI generated from the Langfuse OpenAPI spec using openapi-generator with custom Mustache templates. Most code is generated at
    build time and never checked into version control.
  • langfuse-java-client -- Reference HTTP client built on java.net.http.HttpClient with dual Jackson 2/3 support, request/response logging with sensitive header masking, and automatic HTTP/1.1 fallback for plain HTTP connections.
  • langfuse-java-testcontainers -- LangfuseContainer that orchestrates a full Langfuse environment (PostgreSQL, ClickHouse, Redis, MinIO, web server, worker) for testing via Testcontainers.
  • langfuse-java-legacy -- Existing fern-generated SDK preserved as-is for backward compatibility.

Key design decisions

  • Build-time generation over checked-in code: Uses the Fern-generated OpenAPI spec as input to openapi-generator with custom templates, because Fern's generated Java code lacks builder patterns, dual Jackson support, request parameter objects, and JPMS modules.
  • SPI-based client discovery: LangfuseApi.builder() uses ServiceLoader to find the client implementation, allowing framework-specific implementations (Spring, Quarkus) without depending on the reference client.
  • Request parameter objects: All API methods with parameters use a request object with a builder (via useSingleRequestParameter), eliminating long parameter lists (e.g. scoresGetMany had 21 parameters).
  • Protected constructors + builders: Model constructors are protected; all creation goes through builder().
  • Empty container defaults: All List/Map/Set fields default to empty (never null), with @JsonInclude(NON_EMPTY) at the class level so empty optional containers are omitted from serialized JSON.
  • Bean validation: @NotNull, @Size, etc. annotations generated from OpenAPI schema constraints.
  • Dual Jackson 2 + 3: Models carry both com.fasterxml.jackson and tools.jackson annotations. The client auto-detects which Jackson version is on the classpath at runtime.
  • Sync + async APIs: Every API operation has both a synchronous and CompletionStage-based async variant.

Testcontainers

  • Parallel infrastructure startup via Startables.deepStart
  • Web and worker containers start in parallel after infrastructure is ready
  • Singleton container pattern for sharing across test classes
  • getAllLogs() returns a Map<String, String> of all container logs for diagnostics
  • Configurable via builder API with sensible defaults aligned to docker-compose.yml
  • Optional<Duration> for ingestion queue delay and ClickHouse write interval settings

Test coverage

tests (sync + async) across 19 API areas, all running against a real Langfuse environment via Testcontainers:

Health, Ingestion, Traces, Prompts, Prompt Versions, Scores, Score Configs, Datasets, Dataset Items, Dataset Run Items, Models, Projects, Observations, Sessions, Comments, Annotation Queues, plus LegacyScoreV1 and LegacyObservationsV1 used as helpers.

Other changes

  • JPMS module-info.java for API and client modules
  • Request/response logging with logRequests(), logResponses(), prettyPrint() builder options
  • HTTP/1.1 fallback in ApiClient for plain http URLs (avoids HTTP/2 upgrade issues)
  • README documentation for root project and all three new modules
  • Awaitility added for eventual-consistency assertions in tests
  • Container labels (com.langfuse.testcontainers.service) on all Docker containers

Closes #31

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 28, 2026

CLA assistant check
All committers have signed the CLA.

@edeandrea
Copy link
Copy Markdown
Author

edeandrea commented May 28, 2026

Hi @Steffen911 Please see my initial implementation of the separation. Its a lot and I'm happy to iterate on it. The biggest thing I tried to achieve is to keep as much build-time auto-generation as possible.

Fern itself is not very customizeable, so I started from an OpenAPI document, which can be generated from fern. There is very little code actually checked into version control - most of the power comes from the fact it is generated at build time.

Happy to answer any questions you may have.

You might think that 491 file changes is a lot, but more than half of that is simply moving what was there (generated by fern) into a subdirectory so it can be preserved.

…guration options, and testing setup with Testcontainers

Add integration tests for all major APIs, including async and legacy modes

Refactor configuration handling and improve ingestion-related tests

Add async tests for all major APIs

Enable Bean Validation support in `langfuse-java-api`.

Define module-info files for `langfuse-java-api` and `langfuse-java-client`.

Tests

Adding request/response logging

Adding testcontainers

Adding additional apis

Initial separation
@edeandrea
Copy link
Copy Markdown
Author

edeandrea commented Jun 2, 2026

Thought I would mention this too - there are Untyped Map-Like Fields in the Langfuse OpenAPI Spec. I'll open a new issue for this too.

Problem

The Langfuse OpenAPI spec (openapi.yml) has 28 fields across 18 schemas that represent JSON objects (key-value maps) but lack type: object and additionalProperties declarations. These fields have only a description and optionally nullable: true, with no type information.

In the OpenAPI 3.0 specification, a property with no type is treated as "any type" (AnyType). Code generators for strongly-typed languages map this to the language's top-level type — for example, Object in Java, any in TypeScript, or interface{} in Go — instead of a map/dictionary type. This degrades the developer experience because consumers must manually cast these values.

The spec already correctly types the same fields in other schemas (see Correctly Typed Fields below), making this an internal inconsistency rather than a design choice.

How We Identified Which Fields Should Be Typed

We used four criteria to determine which untyped fields should have type: object + additionalProperties: true added:

1. Internal Consistency Within the Spec

The same field name is correctly typed in some schemas but untyped in others. For example:

Field Correctly Typed In Missing Type In
metadata Project, OrganizationProject, legacyCreateScoreRequest Trace, Observation, Dataset, BaseScore, and 12 others
modelParameters CreateGenerationBody, UpdateGenerationBody, ObservationBody Observation, ObservationV2
config LlmConnection, UpsertLlmConnectionRequest BasePrompt, CreateChatPromptRequest, CreateTextPromptRequest

If the field is typed as type: object with additionalProperties in one schema, it should be typed the same way everywhere it appears.

2. Langfuse Documentation

The Langfuse documentation explicitly describes these fields as object/dictionary types:

  • metadata: Documented as Record<string, unknown>. The v2→v3 migration guide states: "Only the Record type is supported within our UI and endpoints to perform queries and filter events." Non-object values sent as metadata are coerced into objects (e.g., "test" becomes { "metadata": "test" }).
  • modelParameters: Documented as a string-keyed map ({ "property1": "string", "property2": "string" }). The UI renders it using Object.entries(), confirming it is always an object.
  • config (on prompts): Documented as a freeform JSON dictionary. Accessed via dictionary methods in SDKs (cfg.get("model") in Python, cfg.model in JS/TS).

3. Semantic Analysis

Some fields are structurally always JSON objects by nature:

  • inputSchema / expectedOutputSchema: These hold JSON Schema definitions, which are always JSON objects per the JSON Schema specification.
  • tokenizerConfig: A configuration map for tokenizer settings — inherently key-value.
  • lastConfig (in PromptMeta): The last-used prompt configuration, same semantics as config.

4. Exclusion Criteria — Fields That Should Remain Untyped

We explicitly excluded fields that can legitimately be any JSON value:

  • input / output / expectedOutput: The Langfuse API documentation states these "Can be any JSON" — strings, numbers, arrays, or objects are all valid. Typing these as object would be incorrect.
  • error (in IngestionError): Error payloads can be structured in various ways.
  • log (in SDKLogBody): SDK debug payloads can be any JSON value.

Correctly Typed Fields (8)

These fields already have proper type: object + additionalProperties declarations and serve as the pattern that should be applied to the missing fields:

Schema Field Current Definition
Project metadata type: object, additionalProperties: true
OrganizationProject metadata type: object, additionalProperties: true
legacyCreateScoreRequest metadata type: object, additionalProperties: true
LlmConnection config type: object, additionalProperties: true
UpsertLlmConnectionRequest config type: object, additionalProperties: true
CreateGenerationBody modelParameters type: object, additionalProperties: { $ref: MapValue }
UpdateGenerationBody modelParameters type: object, additionalProperties: { $ref: MapValue }
ObservationBody modelParameters type: object, additionalProperties: { $ref: MapValue }

Fields Missing Type Definitions (28)

These fields should have type: object and additionalProperties: true added:

metadata (19 fields)

Schema Current Definition Nullable
Trace no type specified true
Observation no type specified not set
ObservationV2 no type specified true
ObservationBody no type specified true
OptionalObservationBody no type specified true
BaseScore no type specified not set
BaseScoreV1 no type specified not set
ScoreBody no type specified true
Dataset no type specified not set
DatasetItem no type specified not set
DatasetRun no type specified not set
CreateDatasetItemRequest no type specified true
CreateDatasetRunItemRequest no type specified true
CreateDatasetRequest no type specified true
TraceBody no type specified true
BaseEvent no type specified true

modelParameters (2 fields)

Schema Current Definition Nullable
Observation no type specified not set
ObservationV2 no type specified true

Note: The write-side schemas (CreateGenerationBody, UpdateGenerationBody, ObservationBody) already correctly type modelParameters as type: object with additionalProperties: { $ref: MapValue }.

config (3 fields)

Schema Current Definition Nullable
BasePrompt empty definition ({}) N/A
CreateChatPromptRequest no type specified true
CreateTextPromptRequest no type specified true

Note: LlmConnection.config and UpsertLlmConnectionRequest.config already correctly use type: object with additionalProperties: true.

tokenizerConfig (2 fields)

Schema Current Definition Nullable
Model no type specified not set
CreateModelRequest no type specified true

inputSchema / expectedOutputSchema (4 fields)

Schema Field Current Definition Nullable
Dataset inputSchema no type specified true
Dataset expectedOutputSchema no type specified true
CreateDatasetRequest inputSchema no type specified true
CreateDatasetRequest expectedOutputSchema no type specified true

lastConfig (1 field)

Schema Current Definition Nullable
PromptMeta no type specified not set

Cascading Impact via Schema Composition (30 schemas)

The 28 untyped fields cascade into 30 additional composed schemas via oneOf, allOf, and anyOf. Fixing the base schemas will automatically fix all of these:

Composed Schema Inherits From Untyped Field
TraceWithDetails Trace metadata
TraceWithFullDetails Trace metadata
ObservationsView Observation metadata, modelParameters
BooleanScore BaseScore metadata
CategoricalScore BaseScore metadata
CorrectionScore BaseScore metadata
NumericScore BaseScore metadata
TextScore BaseScore metadata
BooleanScoreV1 BaseScoreV1 metadata
CategoricalScoreV1 BaseScoreV1 metadata
NumericScoreV1 BaseScoreV1 metadata
TextScoreV1 BaseScoreV1 metadata
DatasetRunWithItems DatasetRun metadata
ChatPrompt BasePrompt config
TextPrompt BasePrompt config
CreatePromptRequest CreateChatPromptRequest / CreateTextPromptRequest config
CreateEventBody OptionalObservationBody metadata
UpdateEventBody OptionalObservationBody metadata
CreateEventEvent BaseEvent metadata
CreateGenerationEvent BaseEvent metadata
CreateObservationEvent BaseEvent metadata
CreateSpanEvent BaseEvent metadata
UpdateGenerationEvent BaseEvent metadata
UpdateObservationEvent BaseEvent metadata
UpdateSpanEvent BaseEvent metadata
SDKLogEvent BaseEvent metadata
ScoreEvent BaseEvent metadata
TraceEvent BaseEvent metadata

Fields Correctly Left Untyped (18)

These fields are intentionally untyped because they can be any JSON value (string, number, array, object, or null). No changes needed:

Schema Field Reason
Trace input, output Documented as "Can be any JSON"
Observation input, output Documented as "Can be any JSON"
ObservationV2 input, output Documented as "Can be any JSON"
ObservationBody input, output Documented as "Can be any JSON"
OptionalObservationBody input, output Documented as "Can be any JSON"
TraceBody input, output Documented as "Can be any JSON"
DatasetItem input, expectedOutput Documented as "Can be any JSON"
CreateDatasetItemRequest input, expectedOutput Documented as "Can be any JSON"
IngestionError error Error payloads can be any structure
SDKLogBody log SDK debug payload, any JSON

Suggested Fix

For each of the 28 fields listed above, add type: object and additionalProperties: true to the property definition. For example, change:

metadata:
  nullable: true
  description: Additional metadata of the observation

to:

metadata:
  type: object
  additionalProperties: true
  nullable: true
  description: Additional metadata of the observation

And for the empty-definition case (BasePrompt.config: {}), change to:

config:
  type: object
  additionalProperties: true

@geoand
Copy link
Copy Markdown

geoand commented Jun 3, 2026

langfuse-java-client -- Reference HTTP client built on java.net.http.HttpClient with dual Jackson 2/3 support, request/response logging with sensitive header masking, and automatic HTTP/1.1 fallback for plain HTTP connections.

IMHO, as you are already touching this, you should introduce an HTTP abstraction as done in LangChain4j which allows for the different HTTP clients.
This will make things for you (us?) much easier in the future with Quarkus

@geoand
Copy link
Copy Markdown

geoand commented Jun 3, 2026

So theoretically I would be able to plug in a Jakarta REST Client?

@edeandrea
Copy link
Copy Markdown
Author

langfuse-java-client -- Reference HTTP client built on java.net.http.HttpClient with dual Jackson 2/3 support, request/response logging with sensitive header masking, and automatic HTTP/1.1 fallback for plain HTTP connections.

IMHO, as you are already touching this, you should introduce an HTTP abstraction as done in LangChain4j which allows for the different HTTP clients.
This will make things for you (us?) much easier in the future with Quarkus

That's exactly what's being done here (and was the whole purpose of this PR). There are now 2 modules - an API and a client. They're completely decoupled.

@edeandrea
Copy link
Copy Markdown
Author

So theoretically I would be able to plug in a Jakarta REST Client?

See the quarkus-langfuse extension. That's exactly what it does. For now the quarkus-langfuse extension has a copy of what this pr is doing, but that will get swapped out once this is merged and released

@geoand
Copy link
Copy Markdown

geoand commented Jun 3, 2026

Wonderful!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Break api into 2 parts

3 participants